Português

Um guia completo sobre genéricos TypeScript, cobrindo sua sintaxe, benefícios, uso avançado e melhores práticas para lidar com tipos de dados complexos no desenvolvimento de software global.

Genéricos TypeScript: Dominando Tipos de Dados Complexos para Aplicações Robustas

TypeScript, um superconjunto do JavaScript, capacita os desenvolvedores a escrever código mais robusto e de fácil manutenção por meio da tipagem estática. Entre seus recursos mais poderosos estão os genéricos, que permitem escrever código que pode funcionar com uma variedade de tipos de dados, mantendo a segurança de tipos. Este guia oferece uma exploração abrangente dos genéricos TypeScript, focando em sua aplicação a tipos de dados complexos no contexto do desenvolvimento de software global.

O que são Genéricos?

Genéricos fornecem uma maneira de escrever código reutilizável que pode funcionar com diferentes tipos. Em vez de escrever funções ou classes separadas para cada tipo que você deseja suportar, você pode escrever uma única função ou classe que usa parâmetros de tipo. Esses parâmetros de tipo são espaços reservados para os tipos reais que serão usados quando a função ou classe for chamada ou instanciada. Isso é especialmente útil ao lidar com estruturas de dados complexas onde o tipo de dados dentro dessas estruturas pode variar.

Benefícios de Usar Genéricos

Sintaxe Básica de Genéricos

A sintaxe básica de genéricos envolve o uso de colchetes angulares (< >) para declarar parâmetros de tipo. Esses parâmetros de tipo são tipicamente nomeados T, K, V, etc., mas você pode usar qualquer identificador válido. Aqui está um exemplo simples de uma função genérica:


function identity<T>(arg: T): T {
  return arg;
}

let myString: string = identity<string>("hello");
let myNumber: number = identity<number>(123);
let myBoolean: boolean = identity<boolean>(true);

console.log(myString); // Saída: hello
console.log(myNumber); // Saída: 123
console.log(myBoolean); // Saída: true

Neste exemplo, <T> declara um parâmetro de tipo chamado T. A função identity recebe um argumento do tipo T e retorna um valor do tipo T. Ao chamar a função, você pode especificar explicitamente o parâmetro de tipo (por exemplo, identity<string>) ou deixar o TypeScript inferi-lo com base no tipo do argumento.

Trabalhando com Tipos de Dados Complexos

Genéricos se tornam particularmente valiosos ao lidar com tipos de dados complexos como arrays, objetos e interfaces. Vamos explorar alguns cenários comuns:

Arrays Genéricos

Você pode usar genéricos para criar funções ou classes que funcionam com arrays de diferentes tipos:


function arrayToString<T>(arr: T[]): string {
  return arr.join(", ");
}

let numberArray: number[] = [1, 2, 3, 4, 5];
let stringArray: string[] = ["apple", "banana", "cherry"];

console.log(arrayToString(numberArray)); // Saída: 1, 2, 3, 4, 5
console.log(arrayToString(stringArray)); // Saída: apple, banana, cherry

Aqui, a função arrayToString recebe um array do tipo T[] e retorna uma representação em string do array. Esta função funciona com arrays de qualquer tipo, tornando-a altamente reutilizável.

Objetos Genéricos

Genéricos também podem ser usados para definir funções ou classes que trabalham com objetos de diferentes formatos:


interface Person {
  name: string;
  age: number;
  country: string; // Adicionado país para contexto global
}

interface Product {
  id: number;
  name: string;
  price: number;
  currency: string; // Adicionada moeda para contexto global
}

function displayInfo<T extends { name: string }>(item: T): void {
  console.log(`Name: ${item.name}`);
}

let person: Person = { name: "Alice", age: 30, country: "USA" };
let product: Product = { id: 1, name: "Laptop", price: 1200, currency: "USD" };

displayInfo(person); // Saída: Name: Alice
displayInfo(product); // Saída: Name: Laptop

Neste exemplo, a função displayInfo recebe um objeto do tipo T que deve ter uma propriedade name do tipo string. A cláusula extends { name: string } é uma restrição (constraint), que especifica os requisitos mínimos para o parâmetro de tipo T. Isso garante que a função possa acessar com segurança a propriedade name.

Uso Avançado de Genéricos

Os genéricos do TypeScript oferecem recursos mais avançados que permitem criar código ainda mais flexível e poderoso. Vamos explorar alguns desses recursos:

Múltiplos Parâmetros de Tipo

Você pode definir funções ou classes com múltiplos parâmetros de tipo:


function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

interface Name {
  firstName: string;
}

interface Age {
  age: number;
}

const person: Name = { firstName: "Bob" };
const details: Age = { age: 42 };

const merged = merge(person, details);
console.log(merged.firstName); // Saída: Bob
console.log(merged.age); // Saída: 42

A função merge recebe dois objetos dos tipos T e U e retorna um novo objeto que contém as propriedades de ambos os objetos. Esta é uma maneira poderosa de combinar dados de diferentes fontes.

Restrições Genéricas

Como mostrado anteriormente, as restrições permitem que você restrinja os tipos que podem ser usados com um parâmetro de tipo genérico. Isso garante que o código genérico possa operar com segurança nos tipos especificados.


interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

loggingIdentity([1, 2, 3]); // Saída: 3
loggingIdentity("hello"); // Saída: 5
// loggingIdentity(123); // Erro: O argumento do tipo 'number' não é atribuível ao parâmetro do tipo 'Lengthwise'.

A função loggingIdentity recebe um argumento do tipo T que deve ter uma propriedade length do tipo number. Isso garante que a função possa acessar com segurança a propriedade length.

Classes Genéricas

Genéricos também podem ser usados com classes:


class DataStorage<T> {
  private data: T[] = [];

  addItem(item: T) {
    this.data.push(item);
  }

  removeItem(item: T) {
    this.data = this.data.filter(d => d !== item);
  }

  getItems(): T[] {
    return [...this.data];
  }
}

const textStorage = new DataStorage<string>();
textStorage.addItem("apple");
textStorage.addItem("banana");
textStorage.removeItem("apple");
console.log(textStorage.getItems()); // Saída: [ 'banana' ]

const numberStorage = new DataStorage<number>();
numberStorage.addItem(1);
numberStorage.addItem(2);
numberStorage.removeItem(1);
console.log(numberStorage.getItems()); // Saída: [ 2 ]

A classe DataStorage pode armazenar dados de qualquer tipo T. Isso permite que você crie estruturas de dados reutilizáveis que são seguras quanto ao tipo.

Interfaces Genéricas

Interfaces genéricas são úteis para definir contratos que podem funcionar com diferentes tipos. Por exemplo:


interface Result<T, E> {
  success: boolean;
  data?: T;
  error?: E;
}

interface User {
  id: number;
  username: string;
  email: string;
}

interface ErrorMessage {
  code: number;
  message: string;
}

function fetchUser(id: number): Result<User, ErrorMessage> {
  if (id === 1) {
    return { success: true, data: { id: 1, username: "john.doe", email: "john.doe@example.com" } };
  } else {
    return { success: false, error: { code: 404, message: "User not found" } };
  }
}

const userResult = fetchUser(1);
if (userResult.success) {
  console.log(userResult.data.username);
} else {
  console.log(userResult.error.message);
}

A interface Result define uma estrutura genérica para representar o resultado de uma operação. Ela pode conter dados do tipo T ou um erro do tipo E. Este é um padrão comum para lidar com operações assíncronas ou operações que podem falhar.

Tipos Utilitários e Genéricos

O TypeScript fornece vários tipos utilitários integrados que funcionam bem com genéricos. Esses tipos utilitários podem ajudá-lo a transformar e manipular tipos de maneiras poderosas.

Partial<T>

Partial<T> torna todas as propriedades do tipo T opcionais:


interface Person {
  name: string;
  age: number;
}

type PartialPerson = Partial<Person>;

const partialPerson: PartialPerson = { name: "Alice" }; // Válido

Readonly<T>

Readonly<T> torna todas as propriedades do tipo T somente leitura:


interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = Readonly<Person>;

const readonlyPerson: ReadonlyPerson = { name: "Bob", age: 42 };
// readonlyPerson.age = 43; // Erro: Não é possível atribuir a 'age' porque é uma propriedade somente leitura.

Pick<T, K>

Pick<T, K> seleciona um conjunto de propriedades K do tipo T:


interface Person {
  name: string;
  age: number;
  email: string;
}

type NameAndAge = Pick<Person, "name" | "age">;

const nameAndAge: NameAndAge = { name: "Charlie", age: 28 };

Omit<T, K>

Omit<T, K> remove um conjunto de propriedades K do tipo T:


interface Person {
  name: string;
  age: number;
  email: string;
}

type PersonWithoutEmail = Omit<Person, "email">;

const personWithoutEmail: PersonWithoutEmail = { name: "David", age: 35 };

Record<K, T>

Record<K, T> cria um tipo com chaves K e valores do tipo T:


type CountryCodes = "US" | "CA" | "UK" | "DE" | "FR" | "JP" | "CN" | "IN" | "BR" | "AU"; // Lista expandida para contexto global
type Currency = "USD" | "CAD" | "GBP" | "EUR" | "JPY" | "CNY" | "INR" | "BRL" | "AUD"; // Lista expandida para contexto global

type CurrencyMap = Record<CountryCodes, Currency>;

const currencyMap: CurrencyMap = {
  "US": "USD",
  "CA": "CAD",
  "UK": "GBP",
  "DE": "EUR",
  "FR": "EUR",
  "JP": "JPY",
  "CN": "CNY",
  "IN": "INR",
  "BR": "BRL",
  "AU": "AUD",
};

Tipos Mapeados

Tipos mapeados permitem que você transforme tipos existentes iterando sobre suas propriedades. Esta é uma maneira poderosa de criar novos tipos com base nos existentes. Por exemplo, você pode criar um tipo que torna todas as propriedades de outro tipo somente leitura:


interface Person {
  name: string;
  age: number;
}

type ReadonlyPerson = {
  readonly [K in keyof Person]: Person[K];
};

const readonlyPerson: ReadonlyPerson = { name: "Eve", age: 25 };
// readonlyPerson.age = 26; // Erro: Não é possível atribuir a 'age' porque é uma propriedade somente leitura.

Neste exemplo, [K in keyof Person] itera sobre todas as chaves da interface Person, e Person[K] acessa o tipo de cada propriedade. A palavra-chave readonly torna cada propriedade somente leitura.

Tipos Condicionais

Tipos condicionais permitem que você defina tipos com base em condições. Esta é uma maneira poderosa de criar tipos que se adaptam a diferentes cenários.


type NonNullable<T> = T extends null | undefined ? never : T;

type MaybeString = string | null | undefined;
type StringType = NonNullable<MaybeString>; // string

function getValue<T>(value: T): NonNullable<T> {
  if (value == null) { // Lida com null e undefined
    throw new Error("O valor não pode ser nulo ou indefinido");
  }
  return value as NonNullable<T>;
}

try {
  const validValue = getValue("hello");
  console.log(validValue.toUpperCase()); // Saída: HELLO

  const invalidValue = getValue(null); // Isso irá lançar um erro
  console.log(invalidValue); // Esta linha não será alcançada
} catch (error: any) {
  console.error(error.message); // Saída: O valor não pode ser nulo ou indefinido
}

Neste exemplo, o tipo NonNullable<T> verifica se T é null ou undefined. Se for, ele retorna never, o que significa que o tipo não é permitido. Caso contrário, ele retorna T. Isso permite criar tipos que são garantidamente não nulos.

Melhores Práticas para Usar Genéricos

Aqui estão algumas melhores práticas a serem lembradas ao usar genéricos:

Exemplos em um Contexto Global

Vamos considerar alguns exemplos de como os genéricos podem ser usados em um contexto global:

Conversão de Moeda


interface ConversionRate {
  rate: number;
  fromCurrency: string;
  toCurrency: string;
}

function convertCurrency<T extends ConversionRate>(amount: number, rate: T): number {
  return amount * rate.rate;
}

const usdToEurRate: ConversionRate = { rate: 0.85, fromCurrency: "USD", toCurrency: "EUR" };
const amountInUSD = 100;
const amountInEUR = convertCurrency(amountInUSD, usdToEurRate);
console.log(`${amountInUSD} USD é igual a ${amountInEUR} EUR`); // Saída: 100 USD é igual a 85 EUR

Formatação de Data


interface DateFormatOptions {
  locale: string;
  options: Intl.DateTimeFormatOptions;
}

function formatDate<T extends DateFormatOptions>(date: Date, format: T): string {
  return date.toLocaleDateString(format.locale, format.options);
}

const currentDate = new Date();

const usDateFormat: DateFormatOptions = { locale: "en-US", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const germanDateFormat: DateFormatOptions = { locale: "de-DE", options: { year: 'numeric', month: 'long', day: 'numeric' } };
const japaneseDateFormat: DateFormatOptions = { locale: "ja-JP", options: { year: 'numeric', month: 'long', day: 'numeric' } };

console.log("Data EUA: " + formatDate(currentDate, usDateFormat));
console.log("Data Alemã: " + formatDate(currentDate, germanDateFormat));
console.log("Data Japonesa: " + formatDate(currentDate, japaneseDateFormat));

Serviço de Tradução


interface Translation {
  [key: string]: string; // Permite chaves de idioma dinâmicas
}

interface LanguageData<T extends Translation> {
  languageCode: string;
  translations: T;
}

const englishTranslations: Translation = {
  "hello": "Hello",
  "goodbye": "Goodbye",
  "welcome": "Welcome to our website!"
};

const spanishTranslations: Translation = {
  "hello": "Hola",
  "goodbye": "Adiós",
  "welcome": "¡Bienvenido a nuestro sitio web!"
};

const frenchTranslations: Translation = {
  "hello": "Bonjour",
  "goodbye": "Au revoir",
  "welcome": "Bienvenue sur notre site web !"
};


const languageData: LanguageData<typeof englishTranslations>[] = [
  {languageCode: "en", translations: englishTranslations },
  {languageCode: "es", translations: spanishTranslations },
  {languageCode: "fr", translations: frenchTranslations}
];

function translate<T extends Translation>(key: string, languageCode: string, languageData: LanguageData<T>[]): string {
  const lang = languageData.find(lang => lang.languageCode === languageCode);
  if (!lang) {
    return `Tradução para ${key} em ${languageCode} não encontrada.`;
  }
  return lang.translations[key] || `Tradução para ${key} não encontrada.`;
}

console.log(translate("hello", "en", languageData)); // Saída: Hello
console.log(translate("hello", "es", languageData)); // Saída: Hola
console.log(translate("welcome", "fr", languageData)); // Saída: Bienvenue sur notre site web !
console.log(translate("missingKey", "de", languageData)); // Saída: Tradução para missingKey em de não encontrada.

Conclusão

Os genéricos do TypeScript são uma ferramenta poderosa para escrever código reutilizável e seguro quanto ao tipo que pode trabalhar com tipos de dados complexos. Ao entender a sintaxe básica, os recursos avançados e as melhores práticas dos genéricos, você pode melhorar significativamente a qualidade e a capacidade de manutenção de suas aplicações TypeScript. Ao desenvolver aplicações para um público global, os genéricos podem ajudá-lo a lidar com diversos formatos de dados e convenções culturais, garantindo uma experiência de usuário perfeita para todos.